package server;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.channels.spi.SelectorProvider;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Vector;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;

import common.Player;
import common.Round;
import common.Sendable;

public class NetworkManager implements Runnable {

	private Selector selector;
	private ServerSocketChannel ssc;
	private final int portNumber;
	
	//Only used by the NetworkManager so thread safe.
	private final HashMap<SocketChannel, Client> clientLookup = new HashMap< SocketChannel, Client >();
	
	//Both of these are used by other threads so must be careful when used.
	private final ConcurrentHashMap< Player, Client > playerLookup = new ConcurrentHashMap< Player, Client >();
	private final List< Client > clients = new Vector< Client >();
	
	private final ServerGameState state;
	
	private final SendableTranslator translator = new SendableTranslator();
	
	private final ExecutorService threadPool = Executors.newFixedThreadPool( 2 );
	private final ScheduledExecutorService scheduler;
	
	byte[] byteTransferArray = new byte[100000];
	
	private long totalSent = 0;
	private long totalReceived = 0;
	private int totalDisconnects = 0;
	
	public NetworkManager( int port, ServerGameState state, ScheduledExecutorService scheduler ) {
		this.portNumber = port;
		this.state = state;
		this.scheduler = scheduler;
	}
	
	//Only guaranteed to work after the NetworkManager has started up.
	public NetworkMediator getMediator() {
		return selector == null ? null : new NetworkMediator( selector, clients, playerLookup, scheduler );
	}
	
	public void run() {
		
		try {
			selector = SelectorProvider.provider().openSelector();
			ssc = ServerSocketChannel.open();
			//All sockets must be configured to be non-blocking that are acquired from the selector or bad things will happen.
			ssc.configureBlocking( false );
			
			
			InetSocketAddress ia = new InetSocketAddress( InetAddress.getLocalHost(), portNumber );
			ssc.socket().bind( ia );
//			System.out.println("NetMan: Server running on port " + portNumber + " host " + InetAddress.getLocalHost() );
			
		} catch (IOException e) {
			System.err.println("NetMan: Unable to open a ServerSocket on port: " + portNumber );
			e.printStackTrace();
			
			threadPool.shutdownNow();
			if( scheduler != null )
				scheduler.shutdownNow();
			return;
		}
		
		try {
			ssc.register( selector, SelectionKey.OP_ACCEPT );
			
			//Interruption on this thread will cause the NetworkManager to shutdown.
			while(!Thread.interrupted())
				while( selector.select() >= 0  ) {
//				System.out.println("NetMan: Going through the selection set. (" + selector.keys().size() + ")" );
				
//					printInfo();
					
					Iterator<SelectionKey> selectedKeys = selector.selectedKeys().iterator();
			        while( selectedKeys.hasNext() )  {
			        	SelectionKey key = (SelectionKey) selectedKeys.next();
			        	selectedKeys.remove();
			        	
			        	if(!key.isValid()) //If the key is invalidated, skip over it.
			        		continue;
						
						if( key.isAcceptable() ) {
							//The ServerSocketChannel is the only one that will ever be registered for ACCEPT
							ServerSocketChannel channel = (ServerSocketChannel) key.channel();
							
							SocketChannel sc = channel.accept();
							
							if( sc != null ) {
//								System.out.println("NetMan: New client connected!");
								sc.configureBlocking( false );
								
								sc.register( selector, SelectionKey.OP_READ );
								
								Client client = new Client( sc );
								
								// Both concurrent data structures so safe
								clientLookup.put(sc, client );
								clients.add( client );
							}
						} else if( key.isReadable() ) {
							read( key );
						} else if( key.isWritable() ) {
							write( key );
						}
					}
				}
			
			
		} catch (ClosedChannelException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		}
		
//		System.out.println("NetMan: Shutting down...");
		
		try {
			selector.close();
			ssc.close();
		} catch (IOException e) {
			e.printStackTrace();
		}
	}
	
	public void printServerStats() {
		System.out.println("Server info: ");
		System.out.println("Number of clients connected: " + clients.size() );
		System.out.println("Total Disconnects: " + totalDisconnects );
		System.out.println("Total Bytes Sent: " + totalSent );
		System.out.println("Total Bytes Received: " + totalReceived );
	}
	
	private void read( SelectionKey key ) throws IOException {
//		System.out.println("NetMan: Reading phase.");
		SocketChannel sc = (SocketChannel) key.channel();

		Client client = clientLookup.get( sc );
		
		if( client == null )
			return;
		
		int amountRead = readFromSocket( key, sc, client );
		
		//If the amountRead was -1, the client dc'd or something bad happened to the connection
		//amountRead zero means nothing is there to process new so don't bother.
		if( amountRead == -1 || amountRead == 0 )
			return;
		
		client.offSet += amountRead;
		
		while( true )
		{
			if( client.offSet >= 4 && client.objectLength == 0 )
				//According to the protocol, the first 4 bytes will be the int length of the following object
				//Read it in and set the objectLength.
				client.objectLength = client.bufferedObject.getInt(0);
			else if( client.objectLength == 0 )
				//Break out if an object length wasn't able to be read this time around (aka, clientOffset < 4 )
				break;
			
			if( client.objectLength != 0 && client.objectLength <= client.offSet+4 ) {
				//If the client offSet is <= to the first integer + the object length, that object can be read in without blocking

				readObject( client );
				
				if( client.objectLength != client.offSet + 4) {
					//This case covers when the bufferedObject in the client was holding more bytes 
					//than the transfered object actually required
					//This means the next object is in the bufferedObject and must be preserved for the next round of transfers.
					
					//Seek to just past the end of the object ( size(Integer) + size( Object ) )
					client.bufferedObject.position( client.objectLength+4 );
					
					//Number of extraBytes in the bufferedObject.
					int extraBytes = client.offSet - (client.objectLength + 4);
					
					//Read in all of the bytes of the object into the transfer array
					//The transfer array only has one thread accessing it so it is safe.
					client.bufferedObject.get( byteTransferArray, 0, extraBytes );
					
					//Dump all of the data out of the bufferedObject
					client.bufferedObject.clear();
					
					//Put all the extra bytes back into the bufferedObject so that they are at the front of the array now.
					client.bufferedObject.put( byteTransferArray, 0, extraBytes );
					client.offSet = extraBytes;
				} else {
					//The object was exactly the size of the bufferedObject
					// so clear it and set the offset to zero.
					client.bufferedObject.clear();
					client.offSet = 0;
				}

				//The object was read in so reset the objectLength
				client.objectLength = 0;
				
			} else {
				//Break out if there isn't enough information stored to read the object in.
				break;
			}

		}
		
	}
	
	private void write( SelectionKey key ) throws IOException {
//		System.out.println("NetMan: Writing Phase.");
		SocketChannel sc = (SocketChannel) key.channel();
		
		Client client = clientLookup.get(sc);
		
		//Synchronize on the sendable output array of the client to make sure no modifications occur while iterating over it.
		
		try {
			synchronized( client.output ) {
				
				for( Sendable s : client.output ) {
					writeObject( sc, s );
//					System.out.println("Writing Object: " + s.getType() );
				}
				
				//Decrement the pendingWrites by the number of sendables sent.
				int currentPendingWrites = client.pendingWrites.addAndGet( -1 * client.output.size() );
				
				if( currentPendingWrites == 0 ) {
					//There are no more pending writes so change the interest OPs back to reading.
					key.interestOps( SelectionKey.OP_READ );
					selector.wakeup();
//					System.out.println("NetMan: interest set to Reading.");
				}
					
				//Flush the sendable output array.
				client.output.clear();
			}
		
		} catch( IOException e ) {
			disconnectClient( key, sc, client );
		}
		
	}
	
	private void readObject( Client client ) throws IOException {
		//Allocate a byteArray the size of the object.
		
		//Seek past the first integer, the object itself starts on byte 4
		client.bufferedObject.position(4);
		//Read the object into the byteArray. Guaranteed to be the full object transfered into the byteArray
		client.bufferedObject.get(byteTransferArray, 0, client.objectLength);
		
		//Wrap it in a ObjectInputStream so the Sendable can be extracted
		ObjectInputStream is = new ObjectInputStream( new ByteArrayInputStream( byteTransferArray ) );
		
		try {
			Object o = is.readObject();
			
			//Check if the object read is a sendable.
			if( o instanceof Sendable ) {
				Sendable received = (Sendable) o;
				
//				System.out.println("NetMan: Received " + received.getType() );
				
				ServerStateMediator server = new ServerStateMediator(state, client, received, playerLookup);
				
				Runnable r = translator.translate( getMediator(), server, received );
				
				if( r != null )
					threadPool.execute( r );
				else
					System.err.println("NetMan: Unable to translate the sendable to a runnable!");
				
			} else {
				System.err.println("NetMan: Received an object that is not a Sendable!");
				//TODO: Maybe disconnect the client?
			}
			
		} catch (ClassNotFoundException e) {
			e.printStackTrace();
		}
	}
	
	private int readFromSocket( SelectionKey key, SocketChannel sc, Client client ) throws IOException {
		int amountRead = 0;
		try {
			//Reads whatever is available into the client's buffer at the last position it wrote to
			amountRead = sc.read( client.bufferedObject );
		} catch( IOException e ) {
//			System.out.println("NetMan: Client disconnected forcibly!");
			disconnectClient( key, sc, client );
			return -1;
		}
		
		if( amountRead == -1 ) {
//			System.out.println("NetMan: Client disconnected gracefully!");
			disconnectClient( key, sc, client );
			return -1;
		}
		
		totalReceived += amountRead;
		
		return amountRead;
	}
	
	private void disconnectClient( SelectionKey key, SocketChannel sc, Client client ) throws IOException {		
		//Concurrent safe operations
		
		//MARKED: Ready-up code for removal of players from the round they were in
		Player p = client.player;
		if( p != null && p.getRound() != null ) {
			Round r = p.getRound();
			if( r.removePlayer( p ) )
				getMediator().sendToAllInRound( r, r );
		}
	
		
		clients.remove( client );
		clientLookup.remove( sc );
		if( client.player != null)
			playerLookup.remove( client.player );
		
		key.cancel();
		sc.close();
		
//		System.out.println("NetMan: Client disconnected!");
		totalDisconnects++;
	}
	
	private void writeObject( SocketChannel channel, Sendable s ) throws IOException {
		ByteArrayOutputStream byteArray = new ByteArrayOutputStream();
		ObjectOutputStream stream = new ObjectOutputStream( byteArray );
		
		stream.writeObject( s );
		
		byte[] bytes = byteArray.toByteArray();
		
		ByteBuffer lengthBuffer = ByteBuffer.allocate( 4 );
		lengthBuffer.putInt(0, bytes.length );
		
		int position = 0;
		while( position != 4 )
			position += channel.write( lengthBuffer );
		
		ByteBuffer buffer = ByteBuffer.wrap( bytes );
		
		position = 0;
		while( position != bytes.length )
			position += channel.write( buffer );
		
		totalSent += position;
	}
}
